/*
 *  Licensed to the Apache Software Foundation (ASF) under one or more
 *  contributor license agreements.  See the NOTICE file distributed with
 *  this work for additional information regarding copyright ownership.
 *  The ASF licenses this file to You under the Apache License, Version 2.0
 *  (the "License"); you may not use this file except in compliance with
 *  the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the License is distributed on an "AS IS" BASIS,
 *  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 *  See the License for the specific language governing permissions and
 *  limitations under the License.
 */
package org.apache.tomcat.util.http;

import org.apache.juli.logging.Log;
import org.apache.juli.logging.LogFactory;
import org.apache.tomcat.util.buf.ByteChunk;
import org.apache.tomcat.util.buf.MessageBytes;
import org.apache.tomcat.util.log.UserDataHelper;
import org.apache.tomcat.util.res.StringManager;

import java.io.PrintWriter;
import java.io.StringWriter;

/**
 * A collection of cookies - reusable and tuned for server side performance.
 * Based on RFC2965 ( and 2109 )
 * <p>
 * This class is not synchronized.
 *
 * @author Costin Manolache
 * @author kevin seguin
 */
public final class Cookies {

	// expected average number of cookies per request
	public static final int INITIAL_SIZE = 4;
	static final StringManager sm =
			StringManager.getManager("org.apache.tomcat.util.http");
	private static final Log log = LogFactory.getLog(Cookies.class);
	private static final UserDataHelper userDataLog = new UserDataHelper(log);
	ServerCookie scookies[] = new ServerCookie[INITIAL_SIZE];
	int cookieCount = 0;
	boolean unprocessed = true;
	MimeHeaders headers;
	private int limit = 200;

	/**
	 * Construct a new cookie collection, that will extract
	 * the information from headers.
	 *
	 * @param headers Cookies are lazy-evaluated and will extract the
	 *                information from the provided headers.
	 */
	public Cookies(MimeHeaders headers) {
		this.headers = headers;
	}

	// XXX will be refactored soon!
	private static boolean equals(String s, byte b[], int start, int end) {
		int blen = end - start;
		if (b == null || blen != s.length()) {
			return false;
		}
		int boff = start;
		for (int i = 0; i < blen; i++) {
			if (b[boff++] != s.charAt(i)) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Returns true if the byte is a whitespace character as
	 * defined in RFC2619
	 * JVK
	 */
	private static final boolean isWhiteSpace(final byte c) {
		// This switch statement is slightly slower
		// for my vm than the if statement.
		// Java(TM) 2 Runtime Environment, Standard Edition (build 1.5.0_07-164)
	    /*
        switch (c) {
        case ' ':;
        case '\t':;
        case '\n':;
        case '\r':;
        case '\f':;
            return true;
        default:;
            return false;
        }
        */
		if (c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f') {
			return true;
		} else {
			return false;
		}
	}

	/**
	 * Unescapes any double quotes in the given cookie value.
	 *
	 * @param bc The cookie value to modify
	 */
	private static void unescapeDoubleQuotes(ByteChunk bc) {

		if (bc == null || bc.getLength() == 0 || bc.indexOf('"', 0) == -1) {
			return;
		}

		int src = bc.getStart();
		int end = bc.getEnd();
		int dest = src;
		byte[] buffer = bc.getBuffer();

		while (src < end) {
			if (buffer[src] == '\\' && src < end && buffer[src + 1] == '"') {
				src++;
			}
			buffer[dest] = buffer[src];
			dest++;
			src++;
		}
		bc.setEnd(dest);
	}

	// -------------------- Indexed access --------------------

	/**
	 * Given the starting position of a token, this gets the end of the
	 * token, with no separator characters in between.
	 * JVK
	 */
	private static final int getTokenEndPosition(byte bytes[], int off, int end,
	                                             int version, boolean isName) {
		int pos = off;
		while (pos < end &&
				(!CookieSupport.isHttpSeparator((char) bytes[pos]) ||
						version == 0 &&
								CookieSupport.ALLOW_HTTP_SEPARATORS_IN_V0 &&
								bytes[pos] != '=' &&
								!CookieSupport.isV0Separator((char) bytes[pos]) ||
						!isName && bytes[pos] == '=' &&
								CookieSupport.ALLOW_EQUALS_IN_VALUE)) {
			pos++;
		}

		if (pos > end) {
			return end;
		}
		return pos;
	}

	/**
	 * Given a starting position after an initial quote character, this gets
	 * the position of the end quote. This escapes anything after a '\' char
	 * JVK RFC 2616
	 */
	private static final int getQuotedValueEndPosition(byte bytes[], int off, int end) {
		int pos = off;
		while (pos < end) {
			if (bytes[pos] == '"') {
				return pos;
			} else if (bytes[pos] == '\\' && pos < (end - 1)) {
				pos += 2;
			} else {
				pos++;
			}
		}
		// Error, we have reached the end of the header w/o a end quote
		return end;
	}

	// -------------------- Adding cookies --------------------

	public void setLimit(int limit) {
		this.limit = limit;
		if (limit > -1 && scookies.length > limit && cookieCount <= limit) {
			// shrink cookie list array
			ServerCookie scookiesTmp[] = new ServerCookie[limit];
			System.arraycopy(scookies, 0, scookiesTmp, 0, cookieCount);
			scookies = scookiesTmp;
		}
	}


	// code from CookieTools

	/**
	 * Recycle.
	 */
	public void recycle() {
		for (int i = 0; i < cookieCount; i++) {
			if (scookies[i] != null) {
				scookies[i].recycle();
			}
		}
		cookieCount = 0;
		unprocessed = true;
	}

	/**
	 * EXPENSIVE!!!  only for debugging.
	 */
	@Override
	public String toString() {
		StringWriter sw = new StringWriter();
		PrintWriter pw = new PrintWriter(sw);
		pw.println("=== Cookies ===");
		int count = getCookieCount();
		for (int i = 0; i < count; ++i) {
			pw.println(getCookie(i).toString());
		}
		return sw.toString();
	}

	public ServerCookie getCookie(int idx) {
		if (unprocessed) {
			getCookieCount(); // will also update the cookies
		}
		return scookies[idx];
	}

	public int getCookieCount() {
		if (unprocessed) {
			unprocessed = false;
			processCookies(headers);
		}
		return cookieCount;
	}

	/**
	 * Register a new, initialized cookie. Cookies are recycled, and
	 * most of the time an existing ServerCookie object is returned.
	 * The caller can set the name/value and attributes for the cookie
	 */
	private ServerCookie addCookie() {
		if (limit > -1 && cookieCount >= limit) {
			throw new IllegalArgumentException(
					sm.getString("cookies.maxCountFail", Integer.valueOf(limit)));
		}

		if (cookieCount >= scookies.length) {
			int newSize = Math.min(2 * cookieCount, limit);
			ServerCookie scookiesTmp[] = new ServerCookie[newSize];
			System.arraycopy(scookies, 0, scookiesTmp, 0, cookieCount);
			scookies = scookiesTmp;
		}

		ServerCookie c = scookies[cookieCount];
		if (c == null) {
			c = new ServerCookie();
			scookies[cookieCount] = c;
		}
		cookieCount++;
		return c;
	}

	/**
	 * Add all Cookie found in the headers of a request.
	 */
	public void processCookies(MimeHeaders headers) {
		if (headers == null) {
			return;// nothing to process
		}
		// process each "cookie" header
		int pos = 0;
		while (pos >= 0) {
			// Cookie2: version ? not needed
			pos = headers.findHeader("Cookie", pos);
			// no more cookie headers headers
			if (pos < 0) {
				break;
			}

			MessageBytes cookieValue = headers.getValue(pos);
			if (cookieValue == null || cookieValue.isNull()) {
				pos++;
				continue;
			}

			if (cookieValue.getType() != MessageBytes.T_BYTES) {
				Exception e = new Exception();
				log.warn("Cookies: Parsing cookie as String. Expected bytes.",
						e);
				cookieValue.toBytes();
			}
			if (log.isDebugEnabled()) {
				log.debug("Cookies: Parsing b[]: " + cookieValue.toString());
			}
			ByteChunk bc = cookieValue.getByteChunk();
			if (CookieSupport.PRESERVE_COOKIE_HEADER) {
				int len = bc.getLength();
				if (len > 0) {
					byte[] buf = new byte[len];
					System.arraycopy(bc.getBytes(), bc.getOffset(), buf, 0, len);
					processCookieHeader(buf, 0, len);
				}
			} else {
				processCookieHeader(bc.getBytes(),
						bc.getOffset(),
						bc.getLength());
			}
			pos++;// search from the next position
		}
	}

	/**
	 * Parses a cookie header after the initial "Cookie:"
	 * [WS][$]token[WS]=[WS](token|QV)[;|,]
	 * RFC 2965
	 * JVK
	 */
	protected final void processCookieHeader(byte bytes[], int off, int len) {
		if (len <= 0 || bytes == null) {
			return;
		}
		int end = off + len;
		int pos = off;
		int nameStart = 0;
		int nameEnd = 0;
		int valueStart = 0;
		int valueEnd = 0;
		int version = 0;
		ServerCookie sc = null;
		boolean isSpecial;
		boolean isQuoted;

		while (pos < end) {
			isSpecial = false;
			isQuoted = false;

			// Skip whitespace and non-token characters (separators)
			while (pos < end &&
					(CookieSupport.isHttpSeparator((char) bytes[pos]) &&
							!CookieSupport.ALLOW_HTTP_SEPARATORS_IN_V0 ||
							CookieSupport.isV0Separator((char) bytes[pos]) ||
							isWhiteSpace(bytes[pos]))) {
				pos++;
			}

			if (pos >= end) {
				return;
			}

			// Detect Special cookies
			if (bytes[pos] == '$') {
				isSpecial = true;
				pos++;
			}

			// Get the cookie/attribute name. This must be a token
			valueEnd = valueStart = nameStart = pos;
			pos = nameEnd = getTokenEndPosition(bytes, pos, end, version, true);

			// Skip whitespace
			while (pos < end && isWhiteSpace(bytes[pos])) {
				pos++;
			}


			// Check for an '=' -- This could also be a name-only
			// cookie at the end of the cookie header, so if we
			// are past the end of the header, but we have a name
			// skip to the name-only part.
			if (pos < (end - 1) && bytes[pos] == '=') {

				// Skip whitespace
				do {
					pos++;
				} while (pos < end && isWhiteSpace(bytes[pos]));

				if (pos >= end) {
					return;
				}

				// Determine what type of value this is, quoted value,
				// token, name-only with an '=', or other (bad)
				switch (bytes[pos]) {
					case '"': // Quoted Value
						isQuoted = true;
						valueStart = pos + 1; // strip "
						// getQuotedValue returns the position before
						// at the last quote. This must be dealt with
						// when the bytes are copied into the cookie
						valueEnd = getQuotedValueEndPosition(bytes,
								valueStart, end);
						// We need pos to advance
						pos = valueEnd;
						// Handles cases where the quoted value is
						// unterminated and at the end of the header,
						// e.g. [myname="value]
						if (pos >= end) {
							return;
						}
						break;
					case ';':
					case ',':
						// Name-only cookie with an '=' after the name token
						// This may not be RFC compliant
						valueStart = valueEnd = -1;
						// The position is OK (On a delimiter)
						break;
					default:
						if (version == 0 &&
								!CookieSupport.isV0Separator((char) bytes[pos]) &&
								CookieSupport.ALLOW_HTTP_SEPARATORS_IN_V0 ||
								!CookieSupport.isHttpSeparator((char) bytes[pos]) ||
								bytes[pos] == '=' && CookieSupport.ALLOW_EQUALS_IN_VALUE) {
							// Token
							valueStart = pos;
							// getToken returns the position at the delimiter
							// or other non-token character
							valueEnd = getTokenEndPosition(bytes, valueStart, end,
									version, false);
							// We need pos to advance
							pos = valueEnd;
						} else {
							// INVALID COOKIE, advance to next delimiter
							// The starting character of the cookie value was
							// not valid.
							UserDataHelper.Mode logMode = userDataLog.getNextMode();
							if (logMode != null) {
								String message = sm.getString(
										"cookies.invalidCookieToken");
								switch (logMode) {
									case INFO_THEN_DEBUG:
										message += sm.getString(
												"cookies.fallToDebug");
										//$FALL-THROUGH$
									case INFO:
										log.info(message);
										break;
									case DEBUG:
										log.debug(message);
								}
							}
							while (pos < end && bytes[pos] != ';' &&
									bytes[pos] != ',') {
								pos++;
							}
							pos++;
							// Make sure no special avpairs can be attributed to
							// the previous cookie by setting the current cookie
							// to null
							sc = null;
							continue;
						}
				}
			} else {
				// Name only cookie
				valueStart = valueEnd = -1;
				pos = nameEnd;

			}

			// We should have an avpair or name-only cookie at this
			// point. Perform some basic checks to make sure we are
			// in a good state.

			// Skip whitespace
			while (pos < end && isWhiteSpace(bytes[pos])) {
				pos++;
			}


			// Make sure that after the cookie we have a separator. This
			// is only important if this is not the last cookie pair
			while (pos < end && bytes[pos] != ';' && bytes[pos] != ',') {
				pos++;
			}

			pos++;

			// All checks passed. Add the cookie, start with the
			// special avpairs first
			if (isSpecial) {
				isSpecial = false;
				// $Version must be the first avpair in the cookie header
				// (sc must be null)
				if (equals("Version", bytes, nameStart, nameEnd) &&
						sc == null) {
					// Set version
					if (bytes[valueStart] == '1' && valueEnd == (valueStart + 1)) {
						version = 1;
					} else {
						// unknown version (Versioning is not very strict)
					}
					continue;
				}

				// We need an active cookie for Path/Port/etc.
				if (sc == null) {
					continue;
				}

				// Domain is more common, so it goes first
				if (equals("Domain", bytes, nameStart, nameEnd)) {
					sc.getDomain().setBytes(bytes,
							valueStart,
							valueEnd - valueStart);
					continue;
				}

				if (equals("Path", bytes, nameStart, nameEnd)) {
					sc.getPath().setBytes(bytes,
							valueStart,
							valueEnd - valueStart);
					continue;
				}

				// v2 cookie attributes - skip them
				if (equals("Port", bytes, nameStart, nameEnd)) {
					continue;
				}
				if (equals("CommentURL", bytes, nameStart, nameEnd)) {
					continue;
				}

				// Unknown cookie, complain
				UserDataHelper.Mode logMode = userDataLog.getNextMode();
				if (logMode != null) {
					String message = sm.getString("cookies.invalidSpecial");
					switch (logMode) {
						case INFO_THEN_DEBUG:
							message += sm.getString("cookies.fallToDebug");
							//$FALL-THROUGH$
						case INFO:
							log.info(message);
							break;
						case DEBUG:
							log.debug(message);
					}
				}
			} else { // Normal Cookie
				if (valueStart == -1 && !CookieSupport.ALLOW_NAME_ONLY) {
					// Skip name only cookies if not supported
					continue;
				}

				sc = addCookie();
				sc.setVersion(version);
				sc.getName().setBytes(bytes, nameStart,
						nameEnd - nameStart);

				if (valueStart != -1) { // Normal AVPair
					sc.getValue().setBytes(bytes, valueStart,
							valueEnd - valueStart);
					if (isQuoted) {
						// We know this is a byte value so this is safe
						unescapeDoubleQuotes(sc.getValue().getByteChunk());
					}
				} else {
					// Name Only
					sc.getValue().setString("");
				}
				continue;
			}
		}
	}

}
